Skip to content

Add opt-in HTTP retry support (REST + PDP)#120

Merged
zeevmoney merged 9 commits into
mainfrom
PER-13890-adding-node-sdk
Jun 24, 2026
Merged

Add opt-in HTTP retry support (REST + PDP)#120
zeevmoney merged 9 commits into
mainfrom
PER-13890-adding-node-sdk

Conversation

@EliMoshkovich

@EliMoshkovich EliMoshkovich commented Feb 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds opt-in HTTP retry support to the SDK for transient failures, built on the axios-retry library. Retries are off by default — existing callers see no behavior change unless they pass a retry config.

  • Opt-in: retries are disabled unless a retry (or pdpRetry) config object is provided; retry: false keeps them off.
  • Exponential backoff with jitter, capped by maxDelay; honors Retry-After (delay-seconds or HTTP-date) on 429.
  • Retries on network errors and status codes 408, 429, 500, 502, 503, 504.
  • Separate config per surface: retry (REST API) and pdpRetry (PDP/OPA, falls back to retry).
  • PDP/OPA additionally retry POST (check operations are idempotent); the REST API never retries POST.
  • PDP/OPA use dedicated internal axios instances, so POST retries are never installed on the shared REST client (no double-writes).

What kind of change does this PR introduce?

Feature (with tests and docs). Linear: PER-13890.

Current behavior

The SDK issues HTTP requests to the Permit REST API and to the PDP with no automatic retry. Any transient failure — a network error, a 429 rate-limit, or a 5xx — surfaces to the caller immediately on the first attempt.

New behavior

Opt-in retry is wired onto the SDK's HTTP clients via axios-retry, driven by the SDK's own delay/condition policy.

  • Opt-in defaultretry/pdpRetry are unset by default, so retries are off and existing callers are unaffected. Providing a retry config object turns them on (3 attempts, exponential backoff by default); retry: false or enabled: false keeps them off.
  • Config (src/config.ts) — two optional fields on IPermitConfig:
    • retry?: IRetryConfig | false — REST API client.
    • pdpRetry?: IRetryConfig | false — PDP/OPA clients; falls back to retry when omitted.
  • Policy (src/utils/retry.ts) — resolveRetryConfig, calculateRetryDelay (exponential backoff + jitter, maxDelay cap, Retry-After), parseRetryAfter, and RETRYABLE_STATUS_CODES. The retry condition is "network error or retryable status," filtered by the configured methods (normalized to uppercase).
  • Mechanics (src/utils/retry-interceptor.ts) — a thin adapter that configures axios-retry on an instance from the resolved policy (retries, retryCondition, retryDelay, shouldResetTimeout, onRetry logging). axios-retry owns retry counting, re-dispatch, per-attempt timeout reset, and request-abort handling.
  • Wiringsrc/index.ts installs retry on the REST API instance; src/enforcement/enforcer.ts installs it on dedicated PDP and OPA instances with POST added to the retried methods.
  • Behavioral notes
    • When enabled, PDP/OPA retry POST (idempotent checks); the REST API does not retry POST, so non-idempotent writes are never repeated.
    • A custom axiosInstance applies to the REST API only; PDP and OPA use dedicated internal axios instances.
  • New exports (src/index.ts): IRetryConfig, RetryConditionFn, RETRYABLE_STATUS_CODES. All additions are optional/additive — existing config and call sites are unchanged.

Files changed

  • src/utils/retry.ts, src/utils/retry-interceptor.ts — new (config/delay policy + axios-retry adapter)
  • src/config.tsretry / pdpRetry config fields
  • src/index.ts — install REST retry + re-exports
  • src/enforcement/enforcer.ts — dedicated PDP/OPA instances + retry (POST enabled for PDP/OPA)
  • src/tests/unit/retry.spec.ts, src/tests/unit/retry-interceptor.spec.ts — unit + behavior tests
  • README.md — retry configuration documentation
  • package.json / yarn.lock — add axios-retry

Test summary

  • Unit tests for the config/delay/parse helpers (resolveRetryConfig opt-in semantics, calculateRetryDelay, parseRetryAfter).
  • Behavior tests through real axios instances: REST and PDP use separate instances, REST does not retry POST while PDP does, retry count, non-retryable status, disallowed method, disabled path, Retry-After end-to-end, and success-after-retry.
  • Unit specs run in CI via the test:unit script (run-s test:*).

- Add built-in retry support with exponential backoff for transient failures
- Retry on network errors and status codes: 408, 429, 500, 502, 503, 504
- Respect Retry-After headers for rate limiting (429)
- Default: 3 retries with exponential backoff (1s, 2s, 4s)
- Add IRetryConfig interface for full configuration control
- Support separate retry config for PDP calls via pdpRetry option
- Enable POST retry for PDP calls (check operations are idempotent)
- Add comprehensive unit tests (33 tests)
- Update README with retry configuration documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@linear

linear Bot commented Feb 18, 2026

Copy link
Copy Markdown

Sort imports alphabetically to satisfy eslint sort-imports rule.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-class HTTP retry support to the SDK (REST API + PDP/OPA clients), including exponential backoff, optional Retry-After handling, and user-configurable retry behavior via retry / pdpRetry options.

Changes:

  • Introduces retry utilities (resolveRetryConfig, backoff/jitter delay calculation, Retry-After parsing) and an Axios response interceptor to perform retries.
  • Wires retry configuration into Permit (API client) and Enforcer (PDP/OPA clients, with POST retries enabled for PDP/OPA).
  • Adds unit tests for retry utilities and documents configuration in the README.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
yarn.lock Lockfile adjustments from dependency resolution changes.
src/utils/retry.ts Retry configuration/types, default condition, Retry-After parsing, and delay calculation utilities.
src/utils/retry-interceptor.ts Axios response interceptor implementing retry logic + logging.
src/index.ts Exposes retry types/constants and installs retry interceptor for REST API calls.
src/enforcement/enforcer.ts Installs retry interceptor for PDP/OPA clients and enables POST retries for idempotent checks.
src/config.ts Adds retry and pdpRetry options to SDK configuration.
src/tests/unit/retry.spec.ts Adds AVA unit tests for retry utilities and basic SDK export/config checks.
README.md Documents retry configuration and examples.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/utils/retry-interceptor.ts
Comment thread src/enforcement/enforcer.ts Outdated
Comment on lines +173 to +176
// For PDP calls, enable POST retry since check operations are idempotent
const pdpRetryWithPost = {
...pdpRetryConfig,
retryMethods: [...pdpRetryConfig.retryMethods, 'POST'],

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pdpRetryWithPost appends 'POST' to retryMethods without deduplicating. If the user already includes POST, this can create duplicates. Consider ensuring uniqueness (e.g., via new Set(...)) when constructing the PDP method list.

Suggested change
// For PDP calls, enable POST retry since check operations are idempotent
const pdpRetryWithPost = {
...pdpRetryConfig,
retryMethods: [...pdpRetryConfig.retryMethods, 'POST'],
// For PDP calls, ensure POST is retried (check operations are idempotent), without duplicates
const pdpRetryWithPost = {
...pdpRetryConfig,
retryMethods: [...new Set([...pdpRetryConfig.retryMethods, 'POST'])],

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the PDP method list is now built with [...new Set([...pdpRetryConfig.retryMethods, 'POST'])], so POST can't be duplicated even if the user already includes it. Methods are also normalized to uppercase in resolveRetryConfig now, so the de-dup is case-correct. Fixed in 12f1a98.

Comment thread src/tests/unit/retry.spec.ts Outdated
Comment thread README.md
Comment thread src/utils/retry.ts Outdated
Comment thread src/utils/retry.ts Outdated
Comment thread src/utils/retry.ts Outdated
Comment thread src/utils/retry-interceptor.ts Outdated

@zeevmoney zeevmoney left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for building this out — retry support is genuinely useful. A few things before merge, most important first (a duplicate-write risk). Note: Copilot already flagged the missing interceptor tests, the parseRetryAfter partial-number parse, and a retryMethods de-dup nit, so those aren't repeated here.

Comment thread src/enforcement/enforcer.ts Outdated
Comment thread src/utils/retry.ts Outdated
Comment thread src/tests/unit/retry.spec.ts
Comment thread README.md Outdated
- Give the PDP enforcer its own dedicated axios instance so PDP-only
  POST retries never land on the shared REST client (no double-writes).
- Default retries to opt-in: DEFAULT_RETRY_CONFIG.enabled=false; passing
  a retry config object opts in. Avoids silent latency change on upgrade.
- parseRetryAfter rejects non-digit delta-seconds (e.g. "5abc").
- resolveRetryConfig returns a fresh copy and normalizes retryMethods to
  uppercase; freeze DEFAULT_RETRY_CONFIG.
- Dedup POST in the PDP retryMethods via Set.
- Use a real Symbol for the retry-count key.
- Add test:unit script so unit specs run in CI; add interceptor tests and
  deterministic Retry-After HTTP-date tests.
- README: document the opt-in default and PDP-vs-REST POST distinction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zeevmoney

Copy link
Copy Markdown
Contributor

A few things before merge, most important first (a duplicate-write risk).

All four points are addressed in 12f1a98:

  • [CRITICAL] shared-instance POST retry — the PDP enforcer now uses its own dedicated axios.create(), so POST retries are installed only on the PDP instance and never touch the shared REST client. Backend writes are not retried.
  • [HIGH] retries on by default — retries are now opt-in (DEFAULT_RETRY_CONFIG.enabled = false; passing a retry config opts in). No silent latency change for existing callers on upgrade.
  • [HIGH] unit tests didn't run in CI — added a test:unit script wired into run-s test:*; 44 unit tests pass locally (retry utilities + a new interceptor suite).
  • [LOW] README behavior note — documented the opt-in default and the PDP-vs-REST POST distinction.

Copilot's nits in the same commit: interceptor tests, digits-only parseRetryAfter, retryMethods de-dup via Set, copy-on-resolve for the mutable default, uppercase method normalization, real Symbol retry-count key, and deterministic Date.now()-stubbed date tests.

zeevmoney and others added 4 commits June 23, 2026 18:30
- Revert the retry-count key from Symbol back to the string
  '__permitRetryCount'. axios's mergeConfig (run on every request()
  during a retry) copies only string-keyed props via Object.keys, so a
  Symbol key was dropped each retry — the counter reset to 0 and caused
  an infinite retry loop. Comment explains why a string is required.
- Add isolation regression tests: REST and PDP use separate axios
  instances, and the REST instance does not retry POST while the PDP
  instance does (caught the Symbol infinite loop).
- config.ts: correct the retry JSDoc to describe opt-in behavior; note
  that a custom axiosInstance applies to REST only (PDP/OPA use
  dedicated internal instances); fix the opaAxiosInstance JSDoc.
- README: note that a custom axiosInstance applies to REST only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Use the battle-tested axios-retry library for the retry mechanics
(counting, re-dispatch, timeout reset, abort handling) instead of the
custom response interceptor that previously shipped an infinite-loop
bug. The public config surface (retry/pdpRetry, IRetryConfig) and our
delay/condition policy (resolveRetryConfig, calculateRetryDelay,
parseRetryAfter) are unchanged — retry-interceptor.ts is now a thin
adapter translating the resolved config into axiosRetry() options:

- retries = maxRetries; retryCondition = per-instance method filter +
  our network/status condition (REST excludes POST, PDP includes it);
  retryDelay reuses calculateRetryDelay (1-based retryCount -> 0-based
  attempt); shouldResetTimeout = true; onRetry preserves the log line.
- Add axios-retry ^4.5.0 (1 transitive dep, is-retry-allowed).
- Rewrite the interceptor tests as behavior tests through real axios
  instances + a rejecting adapter (isolation, POST distinction, retry
  count, non-retryable status, disabled path, retryCount mapping).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the coverage gap from the axios-retry migration review:
- a retryable GET that fails once then succeeds resolves (proves a retry
  actually recovers, not just exhausts);
- a 429 with `retry-after: 2` schedules a 2000ms delay, proving the
  Retry-After header flows through calculateRetryDelay via the
  axios-retry retryDelay callback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Delete NON_RETRYABLE_STATUS_CODES: it was defined and publicly
  re-exported but never used in any retry logic (the condition uses
  RETRYABLE_STATUS_CODES). Drop its definition, the index.ts re-export,
  and the tests that only asserted its value.
- Make DEFAULT_RETRY_METHODS module-private; it's only used in-module by
  DEFAULT_RETRY_CONFIG and imported nowhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zeevmoney zeevmoney changed the title Add retry mechanism for HTTP requests Add opt-in HTTP retry support (REST + PDP) Jun 23, 2026
@zeevmoney zeevmoney requested a review from Copilot June 23, 2026 20:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 10 changed files in this pull request and generated 7 comments.

Comment thread src/utils/retry.ts
Comment thread src/utils/retry-interceptor.ts
Comment thread src/index.ts Outdated
Comment thread README.md
Comment thread src/tests/unit/retry.spec.ts Outdated
Comment thread src/tests/unit/retry.spec.ts Outdated
Comment thread src/utils/retry.ts
- index.ts: strip POST from the REST retryMethods regardless of user
  config, so the REST client never retries non-idempotent writes (hard
  guarantee, symmetric with the enforcer adding POST for PDP/OPA).
- retry.ts: clarify that maxRetries is the number of retries after the
  initial request (default 3 = up to 4 attempts); freeze the
  DEFAULT_RETRY_METHODS array so the exported DEFAULT_RETRY_CONFIG can't
  be mutated by consumers (Object.freeze was shallow).
- retry-interceptor.ts: reword the retry log to "retry N/M" to avoid
  implying total attempts.
- README: clarify "3 retries (up to 4 total attempts)".
- tests: mark the two Date.now()-stubbing HTTP-date tests serial to
  avoid concurrent races; add tests for the frozen default and for REST
  refusing to retry POST even when the user adds it to retryMethods.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 10 changed files in this pull request and generated 3 comments.

Comment thread src/utils/retry.ts Outdated
Comment thread src/enforcement/enforcer.ts
Comment thread src/index.ts
- Freeze RETRYABLE_STATUS_CODES (readonly number[]) so the exported
  array can't be mutated to change SDK-wide retry behavior.
- Skip installing the REST retry interceptor when the POST-filtered
  retryMethods is empty (e.g. user passes retryMethods: ['POST']),
  avoiding a no-op interceptor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 10 changed files in this pull request and generated no new comments.

@zeevmoney zeevmoney left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved: CI green, mergeable, no blocking reviews from other reviewers. Squash-merging.

@zeevmoney zeevmoney merged commit 4a459ef into main Jun 24, 2026
3 checks passed
@zeevmoney zeevmoney deleted the PER-13890-adding-node-sdk branch June 24, 2026 10:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants